summaryrefslogtreecommitdiff
path: root/app/[lng]/partners/(partners)/general-contract-review/[contractId]/vendor-contract-review-client.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'app/[lng]/partners/(partners)/general-contract-review/[contractId]/vendor-contract-review-client.tsx')
-rw-r--r--app/[lng]/partners/(partners)/general-contract-review/[contractId]/vendor-contract-review-client.tsx423
1 files changed, 423 insertions, 0 deletions
diff --git a/app/[lng]/partners/(partners)/general-contract-review/[contractId]/vendor-contract-review-client.tsx b/app/[lng]/partners/(partners)/general-contract-review/[contractId]/vendor-contract-review-client.tsx
new file mode 100644
index 00000000..ee5815d6
--- /dev/null
+++ b/app/[lng]/partners/(partners)/general-contract-review/[contractId]/vendor-contract-review-client.tsx
@@ -0,0 +1,423 @@
+'use client'
+
+import React, { useState, useEffect, useRef } from 'react'
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
+import { Button } from '@/components/ui/button'
+import { Label } from '@/components/ui/label'
+import { Textarea } from '@/components/ui/textarea'
+import { Badge } from '@/components/ui/badge'
+import { Alert, AlertDescription } from '@/components/ui/alert'
+import {
+ FileText,
+ Send,
+ Save,
+ LoaderIcon,
+ AlertCircle,
+ Eye,
+ ArrowLeft,
+ MessageSquare
+} from 'lucide-react'
+import { toast } from 'sonner'
+import { useRouter } from 'next/navigation'
+import {
+ getContractForVendorReview,
+ vendorReplyToContractReview,
+ saveVendorCommentDraft
+} from '@/lib/general-contracts/service'
+import type { WebViewerInstance } from '@pdftron/webviewer'
+
+interface VendorContractReviewClientProps {
+ contract: {
+ id: number
+ contractNumber: string
+ revision: number
+ name: string | null
+ status: string
+ type: string | null
+ category: string | null
+ vendorId: number
+ contractAmount: number | null
+ currency: string | null
+ startDate: string | null
+ endDate: string | null
+ specificationType: string | null
+ specificationManualText: string | null
+ contractScope: string | null
+ notes: string | null
+ contractItems: Array<Record<string, unknown>>
+ attachments: Array<{
+ id: number
+ contractId: number
+ documentName: string
+ fileName: string
+ filePath: string
+ vendorComment: string | null
+ shiComment: string | null
+ uploadedAt: Date
+ uploadedById: number
+ }>
+ vendor: {
+ id: number
+ vendorCode: string | null
+ vendorName: string | null
+ } | null
+ }
+ vendorId: number
+}
+
+export function VendorContractReviewClient({
+ contract: initialContract,
+ vendorId
+}: VendorContractReviewClientProps) {
+ const router = useRouter()
+ const [contract, setContract] = useState<VendorContractReviewClientProps['contract']>(initialContract)
+ const [vendorComment, setVendorComment] = useState<string>('')
+ const [isSaving, setIsSaving] = useState(false)
+ const [isSubmitting, setIsSubmitting] = useState(false)
+
+ // PDFTron Viewer 관련 상태
+ const viewerRef = useRef<HTMLDivElement>(null)
+ const instanceRef = useRef<WebViewerInstance | null>(null)
+ const [viewerLoading, setViewerLoading] = useState(false)
+ const [viewerInitialized, setViewerInitialized] = useState(false)
+
+ // 계약 첨부파일에서 기존 Vendor Comment 로드
+ useEffect(() => {
+ if (contract?.attachments && contract.attachments.length > 0) {
+ const firstAttachment = contract.attachments[0]
+ if (firstAttachment.vendorComment) {
+ setVendorComment(firstAttachment.vendorComment)
+ }
+ }
+ }, [contract])
+
+ // PDFTron Viewer 초기화
+ useEffect(() => {
+ if (!viewerRef.current || viewerInitialized) return
+
+ const initializeViewer = async () => {
+ try {
+ setViewerLoading(true)
+ const { default: WebViewer } = await import('@pdftron/webviewer')
+
+ if (!viewerRef.current) return
+
+ const instance = await WebViewer(
+ {
+ path: '/pdftronWeb',
+ licenseKey: process.env.NEXT_PUBLIC_PDFTRON_WEBVIEW_KEY || '',
+ fullAPI: true,
+ enableFilePicker: false,
+ enableMeasurement: false,
+ enableRedaction: false,
+ enableAnnotations: false,
+ enablePrint: false,
+ enableDownload: false,
+ },
+ viewerRef.current
+ )
+
+ instanceRef.current = instance
+ setViewerInitialized(true)
+ setViewerLoading(false)
+
+ // 계약서 초안 PDF가 있으면 로드
+ if (contract?.attachments && contract.attachments.length > 0) {
+ const pdfAttachment = contract.attachments.find(
+ (att) => att.filePath && att.filePath.endsWith('.pdf')
+ )
+ if (pdfAttachment && pdfAttachment.filePath) {
+ // 파일 경로를 완전한 URL로 변환
+ const fileUrl = pdfAttachment.filePath.startsWith('http')
+ ? pdfAttachment.filePath
+ : `${process.env.NEXT_PUBLIC_URL}${pdfAttachment.filePath}`
+
+ instance.UI.loadDocument(fileUrl)
+ }
+ }
+ } catch (error) {
+ console.error('PDFTron Viewer 초기화 오류:', error)
+ setViewerLoading(false)
+ toast.error('문서 뷰어를 초기화할 수 없습니다.')
+ }
+ }
+
+ initializeViewer()
+
+ return () => {
+ if (instanceRef.current) {
+ try {
+ instanceRef.current.UI.dispose()
+ } catch (error) {
+ console.error('Viewer 정리 오류:', error)
+ }
+ }
+ }
+ }, [contract, viewerInitialized])
+
+ // 임시 저장
+ const handleSaveDraft = async () => {
+ if (!vendorComment.trim()) {
+ toast.error('의견을 입력해주세요.')
+ return
+ }
+
+ setIsSaving(true)
+ try {
+ await saveVendorCommentDraft(contract.id, vendorComment, vendorId)
+ toast.success('의견이 임시 저장되었습니다.')
+ } catch (error) {
+ console.error('임시 저장 오류:', error)
+ const errorMessage = error instanceof Error ? error.message : '임시 저장에 실패했습니다.'
+ toast.error(errorMessage)
+ } finally {
+ setIsSaving(false)
+ }
+ }
+
+ // 의견 회신
+ const handleSubmitReply = async () => {
+ if (!vendorComment.trim()) {
+ toast.error('의견을 입력해주세요.')
+ return
+ }
+
+ setIsSubmitting(true)
+ try {
+ await vendorReplyToContractReview(contract.id, vendorComment, vendorId)
+ toast.success('의견이 성공적으로 회신되었습니다.')
+
+ // 계약 정보 다시 로드
+ const updatedContract = await getContractForVendorReview(contract.id, vendorId)
+ setContract({
+ ...updatedContract,
+ name: updatedContract.name || '',
+ } as VendorContractReviewClientProps['contract'])
+
+ // 상태 변경 후 메시지 표시
+ setTimeout(() => {
+ router.push('/partners/dashboard')
+ }, 2000)
+ } catch (error) {
+ console.error('의견 회신 오류:', error)
+ const errorMessage = error instanceof Error ? error.message : '의견 회신에 실패했습니다.'
+ toast.error(errorMessage)
+ } finally {
+ setIsSubmitting(false)
+ }
+ }
+
+ const getStatusLabel = (status: string) => {
+ const statusLabels: Record<string, string> = {
+ 'Draft': '임시저장',
+ 'Request to Review': '조건검토요청',
+ 'Vendor Replied Review': '협력업체 회신',
+ 'SHI Confirmed Review': '당사 검토 확정',
+ 'Contract Accept Request': '계약승인요청',
+ 'Complete the Contract': '계약체결',
+ }
+ return statusLabels[status] || status
+ }
+
+ const getStatusColor = (status: string) => {
+ const statusColors: Record<string, string> = {
+ 'Request to Review': 'bg-yellow-100 text-yellow-800',
+ 'Vendor Replied Review': 'bg-blue-100 text-blue-800',
+ 'SHI Confirmed Review': 'bg-green-100 text-green-800',
+ }
+ return statusColors[status] || 'bg-gray-100 text-gray-800'
+ }
+
+ return (
+ <div className="space-y-6">
+ {/* 헤더 */}
+ <div className="flex items-center justify-between">
+ <div>
+ <div className="flex items-center gap-2 mb-2">
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => router.push('/partners/dashboard')}
+ >
+ <ArrowLeft className="h-4 w-4 mr-2" />
+ 돌아가기
+ </Button>
+ </div>
+ <h1 className="text-3xl font-bold tracking-tight">일반계약 조건검토</h1>
+ <p className="text-muted-foreground">
+ 계약번호: {contract.contractNumber} (Rev.{contract.revision})
+ </p>
+ </div>
+ <div className="flex items-center gap-2">
+ <Badge className={getStatusColor(contract.status)}>
+ {getStatusLabel(contract.status)}
+ </Badge>
+ </div>
+ </div>
+
+ {/* 상태 안내 */}
+ {contract.status === 'Request to Review' && (
+ <Alert>
+ <AlertCircle className="h-4 w-4" />
+ <AlertDescription>
+ 계약 조건 검토를 요청받았습니다. 계약서 초안을 확인하고 의견을 입력해주세요.
+ </AlertDescription>
+ </Alert>
+ )}
+
+ {/* 계약 정보 카드 */}
+ <Card>
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2">
+ <FileText className="h-5 w-5" />
+ 계약 정보
+ </CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ <div className="grid grid-cols-2 gap-4">
+ <div>
+ <Label className="text-sm text-muted-foreground">계약명</Label>
+ <p className="font-medium">{contract.name || '-'}</p>
+ </div>
+ <div>
+ <Label className="text-sm text-muted-foreground">계약금액</Label>
+ <p className="font-medium">
+ {contract.contractAmount?.toLocaleString() || '0'} {contract.currency || 'KRW'}
+ </p>
+ </div>
+ <div>
+ <Label className="text-sm text-muted-foreground">계약기간</Label>
+ <p className="font-medium">
+ {contract.startDate ? new Date(contract.startDate).toLocaleDateString() : '-'} ~{' '}
+ {contract.endDate ? new Date(contract.endDate).toLocaleDateString() : '-'}
+ </p>
+ </div>
+ <div>
+ <Label className="text-sm text-muted-foreground">계약확정범위</Label>
+ <p className="font-medium">{contract.contractScope || '-'}</p>
+ </div>
+ </div>
+ </CardContent>
+ </Card>
+
+ {/* 계약서 초안 뷰어 */}
+ <Card>
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2">
+ <Eye className="h-5 w-5" />
+ 계약서 초안
+ </CardTitle>
+ </CardHeader>
+ <CardContent>
+ <div className="relative" style={{ height: '600px' }}>
+ {viewerLoading && (
+ <div className="absolute inset-0 flex items-center justify-center">
+ <LoaderIcon className="h-8 w-8 animate-spin" />
+ <span className="ml-2">문서를 불러오는 중...</span>
+ </div>
+ )}
+ <div ref={viewerRef} className="w-full h-full" />
+ </div>
+ </CardContent>
+ </Card>
+
+ {/* Vendor Comment 입력 */}
+ {contract.status === 'Request to Review' && (
+ <Card>
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2">
+ <MessageSquare className="h-5 w-5" />
+ 검토 의견 입력
+ </CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ <div className="space-y-2">
+ <Label htmlFor="vendor-comment" className="text-sm font-medium">
+ Vendor Comment <span className="text-red-500">*</span>
+ </Label>
+ <Textarea
+ id="vendor-comment"
+ value={vendorComment}
+ onChange={(e) => setVendorComment(e.target.value)}
+ placeholder="계약 조건에 대한 검토 의견을 입력해주세요."
+ rows={8}
+ className="resize-none bg-yellow-50 border-2 border-yellow-200"
+ required
+ />
+ <p className="text-xs text-muted-foreground">
+ 노란색 배경 필드는 필수 입력 항목입니다.
+ </p>
+ </div>
+ <div className="flex gap-2">
+ <Button
+ onClick={handleSaveDraft}
+ disabled={isSaving || isSubmitting}
+ variant="outline"
+ >
+ {isSaving ? (
+ <>
+ <LoaderIcon className="h-4 w-4 mr-2 animate-spin" />
+ 저장 중...
+ </>
+ ) : (
+ <>
+ <Save className="h-4 w-4 mr-2" />
+ 임시 저장
+ </>
+ )}
+ </Button>
+ <Button
+ onClick={handleSubmitReply}
+ disabled={isSaving || isSubmitting || !vendorComment.trim()}
+ className="flex-1"
+ >
+ {isSubmitting ? (
+ <>
+ <LoaderIcon className="h-4 w-4 mr-2 animate-spin" />
+ 전송 중...
+ </>
+ ) : (
+ <>
+ <Send className="h-4 w-4 mr-2" />
+ 의견 회신
+ </>
+ )}
+ </Button>
+ </div>
+ </CardContent>
+ </Card>
+ )}
+
+ {/* 이미 회신한 경우 */}
+ {/* {contract.status === 'Vendor Replied Review' && (
+ <Card>
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2">
+ <MessageSquare className="h-5 w-5" />
+ 검토 의견
+ </CardTitle>
+ </CardHeader>
+ <CardContent>
+ <div className="space-y-2">
+ <Label className="text-sm font-medium">Vendor Comment</Label>
+ <div className="min-h-[120px] p-4 bg-yellow-50 border-2 border-yellow-200 rounded-lg">
+ {vendorComment ? (
+ <p className="text-sm whitespace-pre-wrap">{vendorComment}</p>
+ ) : (
+ <p className="text-sm text-muted-foreground">의견이 없습니다.</p>
+ )}
+ </div>
+ <Alert>
+ <AlertCircle className="h-4 w-4" />
+ <AlertDescription>
+ 의견이 회신되었습니다. 당사 검토 후 연락드리겠습니다.
+ </AlertDescription>
+ </Alert>
+ </div>
+ </CardContent>
+ </Card>
+ )} */}
+ </div>
+ )
+}
+